Skip to content

terminal: throw when a termios flag assignment fails and clear its mordant baseline entry - #39162

Open
robobun wants to merge 4 commits into
mainfrom
farm/ab6526b3/terminal-termios-setter-errors
Open

robobun wants to merge 4 commits into
mainfrom
farm/ab6526b3/terminal-termios-setter-errors

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun run rust:mordant reports one error_collapsed_to_bool in src/runtime/api/bun/Terminal.rs: set_termios turns the bun_sys::Error from tcsetattr into false, and set_termios_flag (the shared body of the inputFlags / outputFlags / localFlags / controlFlags setters, line 1410 on main) ignores it. The tcgetattr side had the same shape: get_termios mapped its error to None and the setter returned early.
  • So an assignment the PTY did not apply returned as if it had. Two ways to get there:
    • The master fd no longer refers to a tty (ENOTTY / EBADF).
    • On Debian and Ubuntu, whose glibc carries a patch that reads the attributes back after TCSETS: a controlFlags request whose only changes are bits the pty driver forces (CREAD, CS8, no PARENB) fails with EINVAL. Upstream glibc's tcsetattr is a single ioctl, so this part is distribution specific; bun's Linux glibc CI lanes are all Debian/Ubuntu.
  • resize() and setRawMode() on the same object already throw when their ioctl fails, and write() throws the SystemError; node-pty's resize throws on a failed TIOCSWINSZ as well. A setter has no other channel: JSC ignores the return value of a CustomAccessor setter (JSObject::putInlineSlow), and the bun_jsc setter thunk maps Ok(false) and Ok(true) to the same thing, so the "returns false on failure" wording in the bun-types JSDoc never described anything observable.

Fix

  • get_termios / set_termios return bun_sys::Result; the setter throws the error as a SystemError (code, syscall: "ioctl", errno, same conversion write() uses) when either call fails. The getter still reads 0 on failure, and assigning on a closed terminal is still a no-op (both pinned by existing tests).
  • The closed check in the setter now runs after the value coercion, so a valueOf() that closes the terminal takes the no-op path instead of reaching the new throw with an invalid fd. Before this change that window was covered by the error being swallowed.
  • bun-types JSDoc for the four properties says what setting does now. mordant-baseline.toml: the error_collapsed_to_bool entry for Terminal.rs is deleted by hand. A full bun run rust:mordant:baseline regeneration also dropped always_unwrapped_option:src/install/PackageInstall.rs (already removed in install: rename with_alias_of's version parameter and clear its mordant baseline entry #39130) and narrowed_two_ways:src/runtime/node/node_crypto_binding.rs, which are unrelated, so they are left in place here. The reimplemented_helper entry for Terminal.rs is a different site (get_optional_value, line 243) and stays.
  • Tests, test/js/bun/terminal/terminal.test.ts, "termios flags" block:
    • setting flags throws when the terminal's fd is not a PTY (any POSIX): a child process swaps the master fd for a regular file behind the terminal's back; the getter reads 0 and the assignment throws ENOTTY. No libc rejects a termios update on a healthy PTY portably, so this is the deterministic way to reach the throw. (A regular file rather than /dev/null: termios ioctls on /dev/null are ENODEV on macOS, which the first darwin run showed; the throw itself behaved the same there.)
    • setting flags on a terminal closed while coercing the value is a no-op: pins the check order above.
    • NaN clamps to tcflag_t max now restores the original flags between the Infinity and NaN assignments: on the Debian/Ubuntu lanes, assigning the same partly-masked controlFlags value twice is exactly the EINVAL case above, and starting both assignments from the same state is also what makes the comparison meaningful.
  • Verification:
    • The ENOTTY test fails on the current release (USE_SYSTEM_BUN=1, assignment silently returns) and passes with bun bd; bun bd test test/js/bun/terminal/ test/js/node/tty.test.ts: all pass.
    • bun run rust:mordant with this baseline: without the src/ change it reports the Terminal.rs:1410 finding (bun_runtime 1 in target/mordant/over-baseline.txt); with it, nothing over the baseline. The mordant and cargo clippy GitHub checks passed on the first push as well.

Background

  • termios is the POSIX terminal attribute struct; its four flag words (c_iflag, c_oflag, c_lflag, c_cflag) are what Bun.Terminal exposes as the four properties. A setter does tcgetattr on the PTY master, replaces one word, and writes the struct back with tcsetattr(TCSANOW); bun_sys::posix::tcgetattr / tcsetattr return Result<_, bun_sys::Error> tagged as the ioctl syscall.
  • The Linux pty driver always keeps CS8 | CREAD set and parity off regardless of what is written, and the kernel reports success. Debian's glibc packaging (inherited by Ubuntu) patches tcsetattr to read the attributes back and return EINVAL when the requested c_cflag change had no effect; upstream glibc (sysdeps/unix/sysv/linux/tcsetattr.c is one ioctl(TCSETS)), musl, bionic and macOS return the kernel's success. That is why there is no direct PTY-based test for the EINVAL case and the test uses a regular file's fd instead (every POSIX kernel answers termios ioctls on a regular file with ENOTTY).
  • SysErrorJsc::throw converts a bun_sys::Error into the JS SystemError (code such as "ENOTTY", syscall, errno) and throws it; it is the same conversion Terminal's write() and most node:fs paths use.
  • mordant-baseline.toml is a ratchet: it records the accepted finding count per (lint, file), and CI fails only on findings above the recorded count. Once a finding is fixed its entry has to go, otherwise it would hide a new finding of the same lint in that file.
Earlier revision of this PR

The first push also had a test.skipIf(!isGlibc) test asserting that clearing CREAD throws EINVAL, attributing the readback to glibc itself. Review pointed out the readback is Debian's patch, not upstream glibc, so the isGlibc gate would fail the test on Fedora, Arch, Amazon Linux and similar; confirmed against upstream tcsetattr.c and dropped in 2a8bc0d. The ENOTTY test carries the behavior on every libc; 49b6448 switched its stand-in fd from /dev/null to a regular file after the darwin lane reported ENODEV for /dev/null.


no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/terminal/terminal.test.ts

The inputFlags/outputFlags/localFlags/controlFlags setters ran
tcgetattr/tcsetattr on the PTY master and dropped both results, so an
assignment the PTY rejected (EINVAL from glibc when the kernel refuses
the requested c_cflag bits, ENOTTY/EBADF when the fd is no longer a
tty) returned as if it had been applied. A setter's return value is not
observable from JS, so throwing is the only way to report it, and it is
what resize() and setRawMode() already do.

get_termios/set_termios now return bun_sys::Result and the setter
throws the SystemError when either call fails. Reading still yields 0
on failure and assigning on a closed terminal is still a no-op; the
closed check moves after the value coercion so a valueOf() that closes
the terminal takes the no-op path rather than throwing EBADF.

Clears the error_collapsed_to_bool entry for Terminal.rs in
mordant-baseline.toml.
@robobun
robobun requested a review from alii as a code owner August 15, 2026 17:27
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0ee8e0aa-edf3-4047-b345-64cf6b376ff3

📥 Commits

Reviewing files that changed from the base of the PR and between bcab5ed and 49b6448.

📒 Files selected for processing (4)
  • mordant-baseline.toml
  • packages/bun-types/bun.d.ts
  • src/runtime/api/bun/Terminal.rs
  • test/js/bun/terminal/terminal.test.ts
💤 Files with no reviewable changes (1)
  • mordant-baseline.toml

Walkthrough

Changes

The Terminal termios helpers now preserve system errors. Flag setters throw on PTY failures, remain no-ops after closure, and retain existing clamping behavior. Type documentation and POSIX regression tests describe and verify these semantics.

Terminal termios behavior

Layer / File(s) Summary
Propagate termios errors
src/runtime/api/bun/Terminal.rs, mordant-baseline.toml
Termios helpers now return sys::Result. Flag setters propagate tcgetattr and tcsetattr errors while preserving closed-terminal handling and read fallbacks.
Document and test setter behavior
packages/bun-types/bun.d.ts, test/js/bun/terminal/terminal.test.ts
Documentation describes no-op and throwing setter behavior. Tests cover closure during coercion, ENOTTY, and NaN clamping.

Suggested reviewers: alii, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary behavior change and the related Mordant baseline cleanup.
Description check ✅ Passed The description explains the problem, implementation, tests, and verification results in detail, although it uses different headings from the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:07 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit 49b64486 (Build #98736) was cancelled — waiting for the next build...

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — stops swallowing tcsetattr/tcgetattr failures in the termios flag setters and surfaces them as SystemError, matching resize()/setRawMode()/write().

What was reviewed:

  • set_termios_flag: coercion now precedes the closed check so a valueOf() that closes the terminal no-ops instead of hitting the new throw on an invalid fd; getter still returns 0 on failure and closed-terminal set is still a no-op (both pinned by existing tests).
  • Verified empirically on glibc 2.39 that clearing CREAD returns EINVAL (new gated test holds) while c_cflag = tcflag_t::MAX succeeds, so the updated "NaN clamps" loop's controlFlags = Infinity does not throw.
  • get_termios/set_termios have no other callers; set_raw_mode and create_pty_posix go through sys::posix directly and are unaffected.
  • Baseline entry removal is the fixed lint only; the unrelated reimplemented_helper entry for Terminal.rs stays.
Extended reasoning...

Overview

Four files: src/runtime/api/bun/Terminal.rs changes get_termios/set_termios to return sys::Result and makes set_termios_flag throw the bun_sys::Error via SysErrorJsc::throw when either syscall fails; the closed-terminal check moves after coerce_f64 so a valueOf() that closes the terminal takes the existing no-op path. packages/bun-types/bun.d.ts corrects four JSDoc lines (the old "returns false on failure" was never observable — JSC discards CustomAccessor setter return values). mordant-baseline.toml drops the now-fixed error_collapsed_to_bool entry. test/js/bun/terminal/terminal.test.ts adds three tests and hardens the NaN-clamp test.

Security risks

None. No untrusted-input parsing, no auth/crypto, no allocation sizing changes. The only new user-observable path is a thrown SystemError where a syscall error was previously discarded.

Level of scrutiny

Low-medium. The Rust diff is ~15 lines of mechanical error propagation in a single helper with two call sites, both in this file. This is a user-facing behavior change (setter now throws on an ioctl failure it previously swallowed), but the PR argues convincingly that (a) the old behavior was a lint-flagged bug directly contrary to REVIEW.md's "never swallow a failure", (b) the documented "returns false" contract was never observable, and (c) sibling operations on the same object already throw on their ioctl failures. The failure conditions are edge cases: glibc's c_cflag readback rejection, or the master fd being invalidated behind the terminal's back.

Other factors

  • Empirically confirmed on glibc 2.39 that tcsetattr with CREAD cleared on a PTY master fails with EINVAL (validating the isGlibc-gated test) while c_cflag = 0xFFFFFFFF succeeds — so the "NaN clamps" test's controlFlags = Infinity iteration does not throw under the new error propagation. The finder's concern about this readback being removed in newer glibc was examined and refuted by the verifier; the portable ENOTTY subprocess test covers all POSIX platforms regardless.
  • The ENOTTY test relies on POSIX lowest-free-fd semantics to swap /dev/null under the master fd; it asserts swapped: true so a violated assumption fails loudly rather than passing vacuously, and it runs in a fresh subprocess with a warmed fd table.
  • Checked that get_termios/set_termios have no other callers; set_raw_mode uses bun_core::tty::State::set_mode and create_pty_posix calls sys::posix::tcsetattr directly, so the signature change is fully contained.
  • The coerce-before-closed-check reordering does mean coercion side effects now run on an already-closed terminal where they previously didn't, but that is the conventional order (validate arguments before state) and is what makes the new test's valueOf()-closes case take the no-op path instead of the new throw.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Correction to the comment below: the EINVAL readback is not upstream glibc behavior. It comes from the patch Debian and Ubuntu apply to their glibc (upstream tcsetattr is a single ioctl(TCSETS)), which is why it held on every CI glibc lane (all Debian/Ubuntu) but would not on Fedora, Arch or Amazon Linux. The isGlibc-gated test was dropped in 2a8bc0d; the /dev/null ENOTTY test is the one that carries the behavior on every POSIX libc (it passed on Alpine/musl as well). Details in the review thread and the PR description.

Original comment

Nothing to change from this review. One data point worth recording for the glibc-gated test: the CREAD readback returning EINVAL was measured here on glibc 2.41 (Debian 13) and in the review on 2.39, and in the first CI run it held on every glibc lane (Debian 13 x64, x64-asan and aarch64, Ubuntu 25.04 x64 and aarch64). If a future glibc dropped the readback, that test would fail loudly on the affected lane rather than pass vacuously; the /dev/null ENOTTY test is the one that carries the behavior on every POSIX libc (it passed on Alpine/musl as well). The darwin shards are still queued in that run; the only red so far is an Ubuntu aarch64 shard whose artifact download timed out before any test ran.

Comment thread test/js/bun/terminal/terminal.test.ts Resolved
Upstream glibc's tcsetattr is a single TCSETS ioctl; the EINVAL for a
c_cflag change the pty driver refuses comes from the patch Debian and
Ubuntu apply to their glibc, so the isGlibc gate would fail the test on
other glibc distributions. The /dev/null ENOTTY test covers the throw on
every libc.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the earlier feedback was addressed (commit 2a8bc0d drops the isGlibc-gated EINVAL test and the remaining comment now correctly attributes the readback to Debian/Ubuntu's patch).

What was reviewed:

  • set_termios_flag: coercion now runs before the closed check, so a valueOf() that closes the terminal still no-ops instead of reaching get_termios on an invalid fd — pinned by the new test.
  • The portable ENOTTY test self-validates its fd-reuse precondition via swapped: true, so a wrong fd-table assumption fails loudly rather than passing vacuously.
  • get_termios_flag (the getter) still returns 0 on error, so the existing "flags return 0 on closed terminal" contract and flagsAfterSwap: 0 both hold.
  • The added terminal[prop] = original in the NaN test only inserts an extra assignment on a path that was already green on Debian CI; it cannot introduce a new EINVAL.
Extended reasoning...

Overview

Four files: src/runtime/api/bun/Terminal.rs (~15 lines in set_termios_flag / get_termios / set_termios), test/js/bun/terminal/terminal.test.ts (two new tests, one modified), packages/bun-types/bun.d.ts (four JSDoc lines), and one line removed from mordant-baseline.toml. The Rust change makes the four termios flag setters throw the underlying SystemError when tcgetattr/tcsetattr fail, matching resize()/setRawMode()/write() on the same object, and reorders the closed-flag check to run after value coercion.

Prior feedback resolution

My previous review flagged the test.skipIf(!isGlibc) EINVAL test as depending on Debian's local-tcsetaddr.diff rather than upstream glibc. Commit 2a8bc0d removed that test entirely, dropped the isGlibc import, and updated the surviving comment in the NaN-clamp test to say "Debian's and Ubuntu's patched glibc" instead of attributing the readback to glibc generally. The portable /dev/null ENOTTY test remains as the throw-path coverage on every POSIX libc.

Security risks

None. The change surfaces an error that was previously swallowed; no new input is parsed and no trust boundary is crossed. The fd-swap test runs in an isolated subprocess.

Level of scrutiny

Low-to-medium. The Rust change is a small, mechanical error-propagation refactor in a Bun-native API (not a Node compat surface), following the same SysErrorJsc::throw pattern already used by write() a few lines below. The one non-mechanical piece — moving the closed check after coerce_f64 — is explained in-line and pinned by a dedicated test. The mordant baseline entry removal is exactly what the ratchet requires once the finding is fixed.

Other factors

  • The NaN-clamp test's terminal[prop] = Infinity for controlFlags was already present on main and green on Debian CI, so inserting a restore-to-original between the two assignments cannot introduce a Debian-patch EINVAL that wasn't already there.
  • The ENOTTY subprocess test's fd-number-reuse trick is intricate, but it asserts swapped: true and hadFlagsBeforeSwap: true before checking the throw, so a broken precondition produces a clear failure rather than a vacuous pass. The throwaway new Bun.Terminal({}).close() up front handles the lazy dlopen("libutil") on first construction.
  • bun.d.ts wording is now accurate: JSC ignores a CustomAccessor setter's return value, so the old "returns false on failure" was never observable.
  • Bug-hunting system found nothing on this revision.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for whoever picks this up: the code is final at 49b6448.

  • Build on 2a8bc0d (same src/ diff): green on every Linux lane (Debian, Ubuntu, Alpine, x64, aarch64, ASAN), both Windows lanes and darwin, apart from two things: test/bake/deinitialization.test.ts segfaulting in dev server teardown on Windows 2019 (also fails on main, reported separately), and the new termios test on darwin, which showed the setter throwing the SystemError as intended but with ENODEV, because that is what macOS returns for termios ioctls on /dev/null.
  • 49b6448 changes only that fixture: it swaps in a regular file instead, which every POSIX kernel answers with ENOTTY (macOS vn_ioctl on a regular file, the same path isatty() relies on). The build on it (98736) was green on every lane that ran; its two darwin shards were canceled in a queue purge before they started, so the regular-file fixture has not been CI-verified on macOS yet. Retrying just the darwin job on a quieter queue is enough to confirm it.
  • Everything else listed in the runs passed on retry. mordant and cargo clippy pass on the PR.

Termios ioctls on /dev/null fail with ENODEV on macOS; a regular file
fails with ENOTTY on every POSIX kernel, which is what the test asserts.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the earlier isGlibc-gated test was dropped in 2a8bc0d as requested, and 49b6448 switched the ENOTTY fixture from /dev/null to a regular file for macOS.

What was reviewed:

  • set_termios_flag now propagates tcgetattr/tcsetattr errors via SysErrorJsc::throw (matching write()/resize()/setRawMode()); the getter still returns 0 on failure, and the closed check moved after coerce_f64 so a valueOf() that closes the terminal takes the no-op path.
  • Checked that get_termios/set_termios have no other callers (create_pty_posix uses sys::posix::tcgetattr/tcsetattr directly), so the signature change is self-contained.
  • The ENOTTY subprocess test asserts swapped: true so a fixture-level fd-reuse miss is distinguishable from a setter regression; the NaN test's restore-between-assignments avoids Debian's patched-glibc EINVAL.
Extended reasoning...

Overview

Clears one error_collapsed_to_bool mordant finding in src/runtime/api/bun/Terminal.rs by having get_termios/set_termios return sys::Result and having the four termios flag setters (inputFlags/outputFlags/localFlags/controlFlags) throw the bun_sys::Error as a SystemError instead of discarding it. The closed-terminal check is reordered to run after coerce_f64 so a valueOf() that closes the terminal hits the no-op path rather than the new throw. The matching baseline entry is removed, four JSDoc lines in bun.d.ts are updated (the old "returns false" wording described nothing observable — JSC ignores CustomAccessor setter returns), and terminal.test.ts gains two tests plus a restore step in the existing NaN test.

Security risks

None. The change surfaces an already-occurring syscall error to JS instead of swallowing it; no new untrusted-input parsing, no fd or handle lifetime changes.

Level of scrutiny

Low-to-moderate. The native diff is ~15 lines in one function and two trivial helper signature changes with no other callers. It is a user-visible behavior change (setter now throws where it silently returned), but it aligns with sibling methods on the same object (resize(), setRawMode(), write() all already throw on ioctl failure) and with REVIEW.md's "never swallow a failure" rule; the old behavior was documented as something that could never actually be observed.

Other factors

My earlier review flagged the isGlibc-gated EINVAL test as too broad (Debian patch, not upstream glibc); the author confirmed against upstream tcsetattr.c and dropped it in 2a8bc0d, keeping the portable ENOTTY test. 49b6448 then switched the ENOTTY fixture from /dev/null to a regular file after the darwin lane returned ENODEV. Per the author's status comment, 2a8bc0d was green on all Linux (Debian/Ubuntu/Alpine, x64/aarch64/ASAN), Windows, and darwin lanes except an unrelated test/bake/deinitialization.test.ts segfault on Windows 2019 that also fails on main. mordant and cargo clippy checks pass. The remaining review thread is resolved.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

#42589 edits the same function, set_termios_flag. It replaces the coerce_f64 call and the clamp with validate_uint32, so a value that is not an unsigned 32-bit integer throws and never reaches tcsetattr. The two PRs conflict in Terminal.rs and in terminal.test.ts.

If #42589 lands first, this PR keeps its tcgetattr and tcsetattr error throws. Three parts no longer apply:

  • The closed check moved after the coercion. There is no coercion, so no valueOf() can close the terminal inside the setter.
  • The test setting flags on a terminal closed while coercing the value is a no-op. An object is now a TypeError on an open terminal, and valueOf() is never called.
  • The edit of the test NaN clamps to tcflag_t max, same as Infinity. terminal: throw when a termios flag is set to a value that is not a uint32 #42589 removes that test, because NaN now throws a RangeError.

#42589 also rewrites the JSDoc sentence "Setting returns true on success, false on failure", so that hunk needs a manual merge. The clause "throws if the PTY rejects the new flags" from this PR still has to be added there.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants